package org.thinginitself.mixpath;

import org.jaxen.JaxenException;
import org.jaxen.XPath;

import java.io.OutputStream;
import java.io.Writer;
import java.util.*;

/**
 * XPath based API for creating and editing XML documents.
 * Most methods target element and attributes via a name property. For elements,
 * this is the name of the element, for attributes, it is the name of the attribute.
 * Adding and removing elements and attributes with namespaces
 * is achieved by prepending the target node name with a previously mapped prefix or namespace followed by a colon
 * e.g. 'myElement' becomes 'ns:myElement' where 'ns' is a prefix mapped previosuly using addNamespace('ns', "http://example.com');
 *
 * @author Andrew Harrison
 * @version 1.0.0 16/05/2011
 */
public abstract class MiXPath {

    private Map<String, String> namespaces = new HashMap<String, String>();
    private String path;
    private MutableXPath mxp;
    private Object context;
    private List selection;
    private Options options = new Options();

    /**
     * create a MiXPath with a node object. Subclasses will require different objects
     *
     * @param context
     */
    public MiXPath(Object context) {
        try {
            this.context = context;
            this.path = ".";
            this.mxp = createMiXPath(path);
            this.selection = mxp.selectNodes(context);
        } catch (Exception e) {
            throw new RuntimeException("uh oh. can't create a mutable xpath");
        }
    }

    /**
     * add a prefix -> namespace mapping
     * The prefix is the key, the namespace is the value
     * Prefixes that are registered can be used in XPath paths and in operations that take a namespace, i.e., if
     * a prefix is used instead of a namespace, then the prefix will be replaced with the namespace
     *
     * @param prefix
     * @param namespaceURI
     * @return
     */
    public MiXPath addNamespace(String prefix, String namespaceURI) {
        namespaces.put(prefix, namespaceURI);
        return this;
    }

    /**
     * get the map of namespaces.
     *
     * @return
     */
    public Map<String, String> getNamespaces() {
        return Collections.unmodifiableMap(namespaces);
    }

    /**
     * Add children in bulk to the current selection node with the given element name and possible namespace.
     *
     * @param name
     * @param amount
     * @return
     * @throws MiXPathException
     */
    public MiXPath addChildren(String name, int amount) throws MiXPathException {
        String[] names = splitName(name);
        String namespace = names[0];
        name = names[1];
        if (namespace.length() > 0) {
            if (namespaces.containsKey(namespace)) {
                namespace = namespaces.get(namespace);
            }
            for (int i = 0; i < amount; i++) {
                mxp.insertElement(context, name, namespace, selection);
            }
        } else {
            for (int i = 0; i < amount; i++) {
                mxp.insertElement(context, name, selection);
            }
        }
        return this;
    }

    public MiXPath addChildrenWithPath(String path, String name, int amount) throws MiXPathException {
        try {
            String[] names = splitName(name);
            String namespace = names[0];
            name = names[1];
            MutableXPath xpath = createMiXPath(path);
            List selection = xpath.selectNodes(context);
            if (namespace.length() > 0) {
                if (namespaces.containsKey(namespace)) {
                    namespace = namespaces.get(namespace);
                }
                for (int i = 0; i < amount; i++) {
                    mxp.insertElement(context, name, namespace, selection);
                }
            } else {
                for (int i = 0; i < amount; i++) {
                    mxp.insertElement(context, name, selection);
                }
            }
            return this;
        } catch (JaxenException e) {
            throw new MiXPathException(e);
        }
    }


    /**
     * Add a child to all selected nodes. If more than one node is selected,
     * This will add a child to each.
     *
     * @param name
     * @return
     * @throws MiXPathException
     */
    public MiXPath addChild(String name) throws MiXPathException {
        String[] names = splitName(name);
        String namespace = names[0];
        name = names[1];
        if (namespace.length() > 0) {
            if (namespaces.containsKey(namespace)) {
                namespace = namespaces.get(namespace);
            }
            mxp.insertElement(context, name, namespace, selection);
        } else {
            mxp.insertElement(context, name, selection);
        }
        return this;
    }

    public MiXPath addChildWithPath(String path, String name) throws MiXPathException {
        try {
            String[] names = splitName(name);
            String namespace = names[0];
            name = names[1];
            MutableXPath xpath = createMiXPath(path);
            List selection = xpath.selectNodes(context);
            if (namespace.length() > 0) {
                if (namespaces.containsKey(namespace)) {
                    namespace = namespaces.get(namespace);
                }
                mxp.insertElement(context, name, namespace, selection);
            } else {
                mxp.insertElement(context, name, selection);
            }
            return this;
        } catch (JaxenException e) {
            throw new MiXPathException(e);
        }
    }


    /**
     * Add a child with the given name and text content
     *
     * @param name
     * @param text
     * @return
     * @throws MiXPathException
     */
    public MiXPath addChildAndText(String name, String text) throws MiXPathException {
        String[] names = splitName(name);
        String namespace = names[0];
        name = names[1];
        if (namespace.length() > 0) {
            if (namespaces.containsKey(namespace)) {
                namespace = namespaces.get(namespace);
            }
            mxp.insertElement(context, name, namespace, text, selection);
        } else {
            mxp.insertElement(context, name, null, text, selection);
        }
        return this;
    }

    public MiXPath addChildAndTextWithPath(String path, String name, String text) throws MiXPathException {
        try {
            String[] names = splitName(name);
            String namespace = names[0];
            name = names[1];
            MutableXPath xpath = createMiXPath(path);
            List selection = xpath.selectNodes(context);
            if (namespace.length() > 0) {
                if (namespaces.containsKey(namespace)) {
                    namespace = namespaces.get(namespace);
                }
                mxp.insertElement(context, name, namespace, text, selection);
            } else {
                mxp.insertElement(context, name, null, text, selection);
            }
            return this;
        } catch (JaxenException e) {
            throw new MiXPathException(e);
        }
    }


    /**
     * add children in bulk with given name and text content. As many children as there are
     * string in the List are added
     *
     * @param name
     * @param text
     * @return
     * @throws MiXPathException
     */
    public MiXPath addChildren(String name, List<String> text) throws MiXPathException {
        String[] names = splitName(name);
        String namespace = names[0];
        name = names[1];
        if (namespace.length() > 0) {
            if (namespaces.containsKey(namespace)) {
                namespace = namespaces.get(namespace);
            }
            for (int i = 0; i < text.size(); i++) {
                mxp.insertElement(context, name, namespace, text.get(i), selection);
            }
        } else {
            for (int i = 0; i < text.size(); i++) {
                mxp.insertElement(context, name, null, text.get(i), selection);
            }
        }

        return this;
    }

    public MiXPath addChildrenWithPath(String path, String name, List<String> text) throws MiXPathException {
        try {
            String[] names = splitName(name);
            String namespace = names[0];
            name = names[1];
            MutableXPath xpath = createMiXPath(path);
            List selection = xpath.selectNodes(context);
            if (namespace.length() > 0) {
                if (namespaces.containsKey(namespace)) {
                    namespace = namespaces.get(namespace);
                }
                for (int i = 0; i < text.size(); i++) {
                    mxp.insertElement(context, name, namespace, text.get(i), selection);
                }
            } else {
                for (int i = 0; i < text.size(); i++) {
                    mxp.insertElement(context, name, null, text.get(i), selection);
                }
            }

            return this;
        } catch (JaxenException e) {
            throw new MiXPathException(e);
        }
    }

    /**
     * remove children with the given name
     *
     * @param name
     * @return
     * @throws MiXPathException
     */
    public MiXPath removeChildren(String name) throws MiXPathException {
        String[] names = splitName(name);
        String namespace = names[0];
        name = names[1];
        if (namespace.length() > 0) {
            if (namespaces.containsKey(namespace)) {
                namespace = namespaces.get(namespace);
            }
            mxp.removeElements(context, name, namespace, selection);
        } else {
            mxp.removeElements(context, name, selection);
        }
        return this;
    }

    public MiXPath removeChildrenWithPath(String path, String name) throws MiXPathException {
        try {
            String[] names = splitName(name);
            String namespace = names[0];
            name = names[1];
            MutableXPath xpath = createMiXPath(path);
            List selection = xpath.selectNodes(context);
            if (namespace.length() > 0) {
                if (namespaces.containsKey(namespace)) {
                    namespace = namespaces.get(namespace);
                }
                mxp.removeElements(context, name, namespace, selection);
            } else {
                mxp.removeElements(context, name, selection);
            }
            return this;
        } catch (JaxenException e) {
            throw new MiXPathException(e);
        }
    }

    /**
     * remove the current context node. This is the node set by a moveTo(path) call.
     * If the node has no parent the call will fail, but not throw an exception.
     *
     * @return
     * @throws MiXPathException
     */
    public MiXPath removeContext() throws MiXPathException {
        Object remove = context;
        boolean b = moveToInternal("..");
        if (!b) {
            return this;
        }
        boolean c = mxp.removeElement(remove);
        if (!c) {
            throw new MiXPathException("Cannot remove current context");
        }
        return this;
    }

    /**
     * add an attribute the current seleced nodes
     *
     * @param name
     * @param value
     * @return
     * @throws MiXPathException
     */
    public MiXPath addAttribute(String name, String value) throws MiXPathException {
        String[] names = splitName(name);
        String namespace = names[0];
        name = names[1];
        if (namespace.length() > 0) {
            if (namespaces.containsKey(namespace)) {
                namespace = namespaces.get(namespace);
            }
            mxp.insertAttribute(context, name, namespace, value, selection);
        } else {
            mxp.insertAttribute(context, name, value, selection);
        }
        return this;
    }

    /**
     * Adds an attribute without changing the context node or the curent selction.
     * Instead, the selection is made from the context and is then forgotten.
     *
     * @param path
     * @param name
     * @param value
     * @return
     * @throws MiXPathException
     */
    public MiXPath addAttributeWithPath(String path, String name, String value) throws MiXPathException {
        try {
            String[] names = splitName(name);
            String namespace = names[0];
            name = names[1];
            MutableXPath xpath = createMiXPath(path);
            List selection = xpath.selectNodes(context);
            if (namespace.length() > 0) {
                if (namespaces.containsKey(namespace)) {
                    namespace = namespaces.get(namespace);
                }
                xpath.insertAttribute(context, name, namespace, value, selection);
            } else {
                xpath.insertAttribute(context, name, value, selection);
            }
            return this;
        } catch (JaxenException e) {
            throw new MiXPathException(e);
        }
    }


    /**
     * remove an attribute from currently selected nodes
     *
     * @param name
     * @return
     * @throws MiXPathException
     */
    public MiXPath removeAttribute(String name) throws MiXPathException {
        String[] names = splitName(name);
        String namespace = names[0];
        name = names[1];
        if (namespace.length() > 0) {
            if (namespaces.containsKey(namespace)) {
                namespace = namespaces.get(namespace);
            }
            mxp.removeAttribute(context, name, namespace, selection);
        } else {
            mxp.removeAttribute(context, name, selection);
        }
        return this;
    }

    public MiXPath removeAttributeWithPath(String path, String name) throws MiXPathException {
        try {
            String[] names = splitName(name);
            String namespace = names[0];
            name = names[1];
            MutableXPath xpath = createMiXPath(path);
            List selection = xpath.selectNodes(context);
            if (namespace.length() > 0) {
                if (namespaces.containsKey(namespace)) {
                    namespace = namespaces.get(namespace);
                }
                mxp.removeAttribute(context, name, namespace, selection);
            } else {
                mxp.removeAttribute(context, name, selection);
            }
            return this;
        } catch (JaxenException e) {
            throw new MiXPathException(e);
        }
    }


    /**
     * get the current context node
     *
     * @return
     */
    public Object getContext() {
        return context;
    }

    /**
     * get the current path. This is just what the client code has set, i.e. it can be relative
     *
     * @return
     */
    public String getPath() {
        return path;
    }

    /**
     * get the current output options
     *
     * @return
     */
    public Options getOptions() {
        return options;
    }

    /**
     * get the current selection
     * The list itself is unmodifiable, but the objects in the list may well be mutable
     *
     * @return
     */
    public List getSelection() {
        return Collections.unmodifiableList(selection);
    }

    /**
     * get the amount of currently selected nodes.
     *
     * @return
     */
    public int getSelectionSize() {
        return selection.size();
    }

    /**
     * set the current output options
     *
     * @param options
     * @return
     */
    public MiXPath setOptions(Options options) {
        this.options = options;
        return this;
    }

    /**
     * select some nodes. The path should either be absolute, or relative
     * to the current context. The current context is changed through a moveTo(path) call.
     * The initial context will be the Object used to create the MiXPath object.
     *
     * @param path
     * @return
     * @throws MiXPathException
     */
    public MiXPath select(String path) throws MiXPathException {
        try {
            mxp = createMiXPath(path);
            this.selection = mxp.selectNodes(context);
            return this;
        } catch (JaxenException e) {
            throw new MiXPathException(e);
        }
    }

    /**
     * returns true if the path matches any existing nodes.
     * The current context node is used
     * as the context but no new elements are created and it doesn't change the
     * current selection.
     *
     * @param path
     * @return
     * @throws MiXPathException
     */
    public boolean exists(String path) throws MiXPathException {
        try {
            XPath xpath = createXPath(path);
            List res = xpath.selectNodes(context);
            return res != null && res.size() > 0;
        } catch (JaxenException e) {
            throw new MiXPathException(e);
        }
    }

    /**
     * returns the number of existing nodes that match the xpath expression.
     * The current context node is used
     * as the context but no new elements are created and it doesn't change the
     * current selection.
     *
     * @param path
     * @return
     * @throws MiXPathException
     */
    public int selectionCount(String path) throws MiXPathException {
        try {
            XPath xpath = createXPath(path);
            List res = xpath.selectNodes(context);
            if (res != null) {
                return res.size();
            }
            return 0;
        } catch (JaxenException e) {
            throw new MiXPathException(e);
        }
    }

    /**
     * returns a list of existing matching nodes. The current context node is used
     * as the context but no new elements are created and it doesn't change the
     * current selection.
     *
     * @param path
     * @return
     * @throws MiXPathException
     */
    public List selectExisting(String path) throws MiXPathException {
        try {
            XPath xpath = createXPath(path);
            List res = xpath.selectNodes(context);
            if (res == null) {
                return new ArrayList();
            }
            return res;
        } catch (JaxenException e) {
            throw new MiXPathException(e);
        }
    }

    /**
     * split a name into a prefix and a name. The name is the second element. If there is no prefix, the first
     * element is an empty string.
     *
     * @param name
     * @return
     */
    private String[] splitName(String name) {
        int index = name.indexOf(":");
        if (index == -1) {
            return new String[]{"", name};
        } else {
            String pref = name.substring(0, index);
            String local = name.substring(index + 1, name.length());
            return new String[]{pref, local};
        }
    }


    private boolean moveToInternal(String path) throws MiXPathException {
        try {
            MutableXPath tmp = createMiXPath(path);
            Object tmpContext = tmp.selectSingleNode(context);
            if (tmpContext == null) {
                return false;
            }
            this.mxp = tmp;
            this.context = tmpContext;
            this.selection = Arrays.asList(context);
            this.path = path;
            return true;
        } catch (JaxenException e) {
            throw new MiXPathException(e);
        }
    }

    /**
     * changes the current context node and makes it the subject of the selection as well.
     * It is equivalent to setting the context node and calling select(".");
     * If the path does not exist in the document, then it is created.
     * If the path exists and there are multiple nodes with the same path,
     * then first matching node in document order is returned as the context node.
     * <p/>
     * It is important to remember that the XPath expression used in this method
     * must return 1 or more nodes rather than attributes, numbers, booleans etc.
     * After calling this, calls to addChild will add children to the
     * context node and addAttribute will add attributes to it.
     * To select nodes relative to the context node, call select(relativePath)
     * after calling moveTo(path). After this select() all, the subject of the add/remove
     * methods will be the result of the select call.
     * removeContext is nt affected by the select() call. It always attempts to remove
     * node that was previously returned from a moveTo() call.
     *
     * @param path
     * @return
     * @throws MiXPathException
     */
    public MiXPath moveTo(String path) throws MiXPathException {
        moveToInternal(path);
        return this;
    }

    /**
     * set text content on the currently selected nodes.
     *
     * @param text
     * @return
     * @throws MiXPathException
     */
    public MiXPath setText(String text) throws MiXPathException {
        mxp.insertText(context, text, selection);
        return this;
    }

    public MiXPath setTextWithPath(String path, String text) throws MiXPathException {
        try {
            MutableXPath xpath = createMiXPath(path);
            List selection = xpath.selectNodes(context);
            mxp.insertText(context, text, selection);
            return this;
        } catch (JaxenException e) {
            throw new MiXPathException(e);
        }
    }

    /**
     * write to an output stream. The currently set Options are used to determine
     * how the output is configured
     *
     * @param stream
     * @return
     * @throws MiXPathException
     */
    public MiXPath writeTo(OutputStream stream) throws MiXPathException {
        mxp.writeTo(context, stream, getOptions());
        return this;
    }

    /**
     * write to an output writer. The currently set Options are used to determine
     * how the output is configured
     *
     * @param writer
     * @return
     * @throws MiXPathException
     */
    public MiXPath writeTo(Writer writer) throws MiXPathException {
        mxp.writeTo(context, writer, getOptions());
        return this;
    }

    /**
     * implemented by subclasses that know how to traverse/manipulate the underying document model.
     *
     * @param xpath
     * @return
     * @throws MiXPathException
     */
    protected abstract MutableXPath createMiXPath(String xpath) throws MiXPathException;

    protected abstract XPath createXPath(String xpath) throws MiXPathException;


}
